Skip to content

Port ScalarFunctionExpr proto via a typed, erasure-free UDF bridge (alt to #23421)#23449

Closed
adriangb wants to merge 2 commits into
apache:mainfrom
pydantic:scalar-udf-typed-codec-b
Closed

Port ScalarFunctionExpr proto via a typed, erasure-free UDF bridge (alt to #23421)#23449
adriangb wants to merge 2 commits into
apache:mainfrom
pydantic:scalar-udf-typed-codec-b

Conversation

@adriangb

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Alternative to #23421 — same objective (ScalarFunctionExpr declares its own
proto ser/de), different resolution of the ScalarUDF crate-graph problem. Opening
this so the two shapes can be compared side by side.

Rationale for this change

ScalarFunctionExpr is the last expression on the central proto downcast chain
whose serialization needs session-level objects: the embedded ScalarUDF must go
through PhysicalExtensionCodec on encode and resolve via codec + registry on
decode, and reconstruction needs the session ConfigOptions.

The tension: the crate-wide try_to_proto / try_from_proto contexts live in
physical-expr-common, which sits below datafusion-expr in the crate graph
(datafusion-expr depends on it for Arc<dyn PhysicalExpr>), so those contexts
cannot name ScalarUDF without a dependency cycle.

#23421's answer: absorb ScalarFunctionExpr into the crate-wide hook by adding
dyn Any-erased function-codec channels to the contexts (encode_function<F: Any> /
decode_function<F: Any>), with the erasure confined internally and the concrete
type verified at the boundary. Headline win: zero special cases in to_proto.rs.

This PR's answer (option B): keep the erasure out of the codebase entirely. The
expression still declares its own ser/de, but against two fully-typed bridge
traits defined next to it in datafusion-physical-expr (which can name ScalarUDF):

pub trait ScalarUdfProtoEncoder {
    fn encode_child(&self, expr: &Arc<dyn PhysicalExpr>) -> Result<PhysicalExprNode>;
    fn encode_udf(&self, udf: &ScalarUDF) -> Result<Option<Vec<u8>>>;
}
pub trait ScalarUdfProtoDecoder {
    fn decode_child(&self, node: &PhysicalExprNode) -> Result<Arc<dyn PhysicalExpr>>;
    fn decode_udf(&self, name: &str, fun_definition: Option<&[u8]>) -> Result<Arc<ScalarUDF>>;
    fn config_options(&self) -> Arc<ConfigOptions>;
}

datafusion-proto implements them (it can name every type), and the encode/decode
sites downcast to ScalarFunctionExpr and delegate to its inherent
try_to_proto / try_from_proto. No dyn Any anywhere.

The trade

The two designs sit on opposite sides of one unavoidable fork (dispatching a
session-dependent expression from a bottom-crate trait forces erasure; keeping the
type means keeping a concrete arm):

#23421 This PR (B)
to_proto.rs special cases 0 1 typed downcast arm
dyn Any in the codebase internal channels + TypeId routing none
ctx config_options() Result, erroring default typed, provided by the bridge
future AggregateUDF/WindowUDF ride the same erased channels one typed bridge trait each

The motivating case for the whole mechanism (#21835, DynamicFilterPhysicalExpr
reaching private state) is session-free and rides the crate-wide hook happily
under either design; ScalarFunctionExpr is the only expression dragging session
objects across the boundary. B treats it as the one justified exception rather than
reshaping the shared contract to absorb it.

What changes are included in this PR?

Commit 1 — datafusion/physical-expr:

  • ScalarUdfProtoEncoder / ScalarUdfProtoDecoder bridge traits + module docs
    explaining the crate-graph constraint.
  • Inherent ScalarFunctionExpr::try_to_proto / try_from_proto.
  • 6 direct tests over mock bridges (shape, codec-payload passthrough, missing
    return_type, child encode/decode error propagation, decode round-trip).

Commit 2 — datafusion/proto:

  • impl ScalarUdfProtoEncoder for ConverterEncoder; the encode downcast arm delegates.
  • ScalarUdfConverterDecoder (same registry-then-codec lookup order); the
    ExprType::ScalarUdf arm delegates.

Are these changes tested?

  • 6 new direct tests in scalar_function.rs::proto_tests.
  • cargo test -p datafusion-proto --test proto_integration: 190 passed. The scalar-UDF
    round-trip can now only pass through the new bridge.
  • cargo fmt --all and cargo clippy -p datafusion-physical-expr -p datafusion-proto --all-features -- -D warnings are clean.

Are there any user-facing changes?

Wire format is byte-for-byte unchanged. New public API: the two bridge traits and the
two inherent methods (all feature proto, additive/non-breaking).

🤖 Generated with Claude Code

https://claude.ai/code/session_018LmymuRdTRmGthjMhtCijn

adriangb and others added 2 commits July 10, 2026 12:27
`ScalarFunctionExpr` is the one built-in expression whose ser/de needs
session-level objects: the embedded `ScalarUDF` round-trips through the
`datafusion-proto` extension codec + function registry, and reconstruction
needs the session `ConfigOptions`. Those types live *above*
`physical-expr-common` in the crate graph, so they cannot appear on the
crate-wide `PhysicalExpr::try_to_proto` encode/decode contexts without a
dependency cycle.

Rather than erase the function type to `dyn Any` on the crate-wide context,
the expression declares its own ser/de against two fully-typed bridge traits
(`ScalarUdfProtoEncoder` / `ScalarUdfProtoDecoder`) implemented in
`datafusion-proto`, where `ScalarUDF` and the codec are both nameable. No
`dyn Any` is involved anywhere.

Adds the traits, the inherent `try_to_proto` / `try_from_proto` on
`ScalarFunctionExpr`, and 6 direct tests over mock bridges. Wiring in
`datafusion-proto` follows in the next commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LmymuRdTRmGthjMhtCijn
Implement `ScalarUdfProtoEncoder` on `ConverterEncoder` and add a
`ScalarUdfConverterDecoder` implementing `ScalarUdfProtoDecoder` (reproducing
the exact registry-then-codec UDF lookup order). The encode downcast arm and
the decode `ExprType::ScalarUdf` arm now delegate to
`ScalarFunctionExpr::{try_to_proto, try_from_proto}` — the marshalling logic
lives with the expression, with no type erasure.

Wire format is byte-for-byte unchanged; `proto_integration` (190 tests) still
passes, exercising the scalar-UDF round-trip through the new bridge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LmymuRdTRmGthjMhtCijn
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

physical-expr Changes to the physical-expr crates proto Related to proto crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant